在 ASP.NET Core 實作上傳檔案及下載檔案功能算蠻簡易的,但對於上傳大型檔案就稍微麻煩一些,若沒有額外處理,則容易造成 ASP.NET Core 網站崩潰掛點。
本篇將介紹如何在 ASP.NET Core 實作上傳/下載檔案的 API。
同步發佈至個人部落格:
John Wu's Blog - [鐵人賽 Day23] ASP.NET Core 2 系列 - 上傳/下載檔案
建立一個接收檔案的 Controller,在 Action 的參數中,使用 IFormFile
型別,就可以接收到 HTML Form 傳來的檔案。
如果要允許多檔上傳,就在 Action 的參數中使用 List<IFormFile>
集合來接收參數。範例如下:
Controllers\FileController.cs
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace MyWebsite.Controllers
{
[Route("api/[controller]s")]
public class FileController : Controller
{
private readonly static Dictionary<string, string> _contentTypes = new Dictionary<string, string>
{
{".png", "image/png"},
{".jpg", "image/jpeg"},
{".jpeg", "image/jpeg"},
{".gif", "image/gif"}
};
private readonly string _folder;
public FileController(IHostingEnvironment env)
{
// 把上傳目錄設為:wwwroot\UploadFolder
_folder = $@"{env.WebRootPath}\UploadFolder";
}
[HttpPost]
public async Task<IActionResult> Upload(List<IFormFile> files)
{
var size = files.Sum(f => f.Length);
foreach (var file in files)
{
if (file.Length > 0)
{
var path = $@"{_folder}\{file.FileName}";
using (var stream = new FileStream(path, FileMode.Create))
{
await file.CopyToAsync(stream);
}
}
}
return Ok(new { count = files.Count, size });
}
[HttpGet("{fileName}")]
public async Task<IActionResult> Download(string fileName)
{
if (string.IsNullOrEmpty(fileName))
{
return NotFound();
}
var path = $@"{_folder}\{fileName}";
var memoryStream = new MemoryStream();
using (var stream = new FileStream(path, FileMode.Open))
{
await stream.CopyToAsync(memoryStream);
}
memoryStream.Seek(0, SeekOrigin.Begin);
// 回傳檔案到 Client 需要附上 Content Type,否則瀏覽器會解析失敗。
return new FileStreamResult(memoryStream, _contentTypes[Path.GetExtension(path).ToLowerInvariant()]);
}
}
}
此範例有個小缺陷,就是上傳檔名不能重複,如果檔名重複會被複寫。
enctype
使用 multipart/form-data,把 action
指向接收上傳資料的 API,可以用 accept
限制上傳檔案類型。如下:<form method="post" enctype="multipart/form-data" action="/api/files">
<input type="file" name="files" multiple accept="image/*"/>
<br />
<input type="submit" value="Upload" />
</form>
http://localhost:5000/api/files/{檔名}
即可。如果上傳檔案要伴隨著表單資料的話,可以透過 Model 包裝 IFormFile
。如下:
Models\AlbumModel.cs
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
namespace MyWebsite.Models
{
public class AlbumModel
{
public string Title { get; set; }
public DateTime Date { get; set; }
public List<IFormFile> Photos { get; set; }
}
}
Action 在接收的參數就改為包裝後的 Model。例如:
Controllers\FileController.cs
// ...
[Route("album")]
[HttpPost]
public async Task<IActionResult> Album(AlbumModel model)
{
// ...
return Ok(new
{
title = model.Title,
date = model.Date.ToString("yyyy/MM/dd"),
photoCount = model.Photos.Count
});
}
上傳檔案邏輯同上例
FileController.Upload
。
HTML Form 如下:
<form method="post" enctype="multipart/form-data" action="/api/users/album">
名稱:<input type="text" name="title" /><br />
日期:<input type="date" name="date" /><br />
相片:<input type="file" name="photos" multiple accept="image/*" /><br />
<input type="submit" value="送出" />
</form>
透過 IFormFile
上傳檔案,是由 ASP.NET Core 控制緩衝記憶體,如果檔案太大或很頻繁耗用緩衝記憶體,容易使 ASP.NET Core 的緩衝記憶體到達上限,屆時就是它死給你看的時候了。
所以,如果系統會有上傳大檔的需求,又或者是會很頻繁的上傳檔案,強烈建議改用串流的方式,自己實作寫入硬碟位置,避免 ASP.NET Core 控制緩衝記憶體控制到溢位。
由於要自行處理 Request 來的資料,所以要把 原本的 Model Binding 移除 。
建立一個 Attribute 註冊在大型檔案上傳的 API,透過 Resource Filter 在 Model Binding 之前把它移除。
Filters\DisableFormValueModelBindingFilter.cs
using System;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
namespace MyWebsite.Filters
{
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingFilter : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
var formValueProviderFactory = context.ValueProviderFactories
.OfType<FormValueProviderFactory>()
.FirstOrDefault();
if (formValueProviderFactory != null)
{
context.ValueProviderFactories.Remove(formValueProviderFactory);
}
var jqueryFormValueProviderFactory = context.ValueProviderFactories
.OfType<JQueryFormValueProviderFactory>()
.FirstOrDefault();
if (jqueryFormValueProviderFactory != null)
{
context.ValueProviderFactories.Remove(jqueryFormValueProviderFactory);
}
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
}
}
}
從微軟官方範例直接複製 MultipartRequestHelper.cs 使用,這個類別是用來判斷 HTML Form 送來的 multipart/form-data
內容使用。
Helpers\MultipartRequestHelper.cs
using System;
using System.IO;
using Microsoft.Net.Http.Headers;
namespace MyWebsite.Helpers
{
public static class MultipartRequestHelper
{
// Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq"
// The spec says 70 characters is a reasonable limit.
public static string GetBoundary(MediaTypeHeaderValue contentType, int lengthLimit)
{
var boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary);
if (string.IsNullOrWhiteSpace(boundary.Value))
{
throw new InvalidDataException("Missing content-type boundary.");
}
if (boundary.Length > lengthLimit)
{
throw new InvalidDataException($"Multipart boundary length limit {lengthLimit} exceeded.");
}
return boundary.Value;
}
public static bool IsMultipartContentType(string contentType)
{
return !string.IsNullOrEmpty(contentType)
&& contentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0;
}
public static bool HasFormDataContentDisposition(ContentDispositionHeaderValue contentDisposition)
{
// Content-Disposition: form-data; name="key";
return contentDisposition != null
&& contentDisposition.DispositionType.Equals("form-data")
&& string.IsNullOrEmpty(contentDisposition.FileName.Value)
&& string.IsNullOrEmpty(contentDisposition.FileNameStar.Value);
}
public static bool HasFileContentDisposition(ContentDispositionHeaderValue contentDisposition)
{
// Content-Disposition: form-data; name="myfile1"; filename="Misc 002.jpg"
return contentDisposition != null
&& contentDisposition.DispositionType.Equals("form-data")
&& (!string.IsNullOrEmpty(contentDisposition.FileName.Value)
|| !string.IsNullOrEmpty(contentDisposition.FileNameStar.Value));
}
}
}
FileStreamingHelper 是從官方範例 StreamingController.cs 抽出的邏輯,可以讓 Controller 程式碼更簡潔,Stream
實體透過委派傳入,使用上較為彈性。
Helpers\FileStreamingHelper.cs
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
namespace MyWebsite.Helpers
{
public static class FileStreamingHelper
{
private static readonly FormOptions _defaultFormOptions = new FormOptions();
public static async Task<FormValueProvider> StreamFile(this HttpRequest request, Func<FileMultipartSection, Stream> createStream)
{
if (!MultipartRequestHelper.IsMultipartContentType(request.ContentType))
{
throw new Exception($"Expected a multipart request, but got {request.ContentType}");
}
// 把 request 中的 Form 依照 Key 及 Value 存到此物件
var formAccumulator = new KeyValueAccumulator();
var boundary = MultipartRequestHelper.GetBoundary(
MediaTypeHeaderValue.Parse(request.ContentType),
_defaultFormOptions.MultipartBoundaryLengthLimit);
var reader = new MultipartReader(boundary, request.Body);
var section = await reader.ReadNextSectionAsync();
while (section != null)
{
// 把 Form 的欄位內容逐一取出
ContentDispositionHeaderValue contentDisposition;
var hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(section.ContentDisposition, out contentDisposition);
if (hasContentDispositionHeader)
{
if (MultipartRequestHelper.HasFileContentDisposition(contentDisposition))
{
// 若此欄位是檔案,就寫入至 Stream;
using (var targetStream = createStream(section.AsFileSection()))
{
await section.Body.CopyToAsync(targetStream);
}
}
else if (MultipartRequestHelper.HasFormDataContentDisposition(contentDisposition))
{
// 若此欄位不是檔案,就把 Key 及 Value 取出,存入 formAccumulator
var key = HeaderUtilities.RemoveQuotes(contentDisposition.Name).Value;
var encoding = GetEncoding(section);
using (var streamReader = new StreamReader(
section.Body,
encoding,
detectEncodingFromByteOrderMarks: true,
bufferSize: 1024,
leaveOpen: true))
{
var value = await streamReader.ReadToEndAsync();
if (String.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
{
value = String.Empty;
}
formAccumulator.Append(key, value);
if (formAccumulator.ValueCount > _defaultFormOptions.ValueCountLimit)
{
throw new InvalidDataException($"Form key count limit {_defaultFormOptions.ValueCountLimit} exceeded.");
}
}
}
}
// 取得 Form 的下一個欄位
section = await reader.ReadNextSectionAsync();
}
// Bind form data to a model
var formValueProvider = new FormValueProvider(
BindingSource.Form,
new FormCollection(formAccumulator.GetResults()),
CultureInfo.CurrentCulture);
return formValueProvider;
}
private static Encoding GetEncoding(MultipartSection section)
{
MediaTypeHeaderValue mediaType;
var hasMediaTypeHeader = MediaTypeHeaderValue.TryParse(section.ContentType, out mediaType);
// UTF-7 is insecure and should not be honored. UTF-8 will succeed in
// most cases.
if (!hasMediaTypeHeader || Encoding.UTF7.Equals(mediaType.Encoding))
{
return Encoding.UTF8;
}
return mediaType.Encoding;
}
}
}
HTML Form 使用同上述表單資料的範例,上傳檔案的 API 改成如下:
Controllers\FileController.cs
// ...
[Route("album")]
[HttpPost]
[DisableFormValueModelBindingFilter]
public async Task<IActionResult> Album()
{
var photoCount = 0;
var formValueProvider = await Request.StreamFile((file) =>
{
photoCount++;
return System.IO.File.Create($"{_folder}\\{file.FileName}");
});
var model = new AlbumModel{
Title = formValueProvider.GetValue("title").ToString(),
Date = Convert.ToDateTime(formValueProvider.GetValue("date").ToString())
};
// ...
return Ok(new
{
title = model.Title,
date = model.Date.ToString("yyyy/MM/dd"),
photoCount = photoCount
});
}
FormValueProvider
包裝後回傳,並以委派方法讓你實做上傳的事件,以此例來說就是直接以串流的方式直接寫檔。上傳大檔可能還會遇到單一 Request 封包過大的錯誤。
Kestrel
上,預設單一上傳封包是 30,000,000 bytes 大約是 28.6MB,單次 Request 上傳的大小限制可以在 KestrelServerLimits
修改 MaxRequestBodySize
。如下:// ...
public class Program
{
public static void Main(string[] args)
{
BuildWebHost(args).Run();
}
public static IWebHost BuildWebHost(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseStartup<Startup>()
.UseKestrel(options =>
{
// 100MB
options.Limits.MaxRequestBodySize = 100 * 1024 * 1024;
})
.Build();
}
IIS
上,預設單一上傳封包是 30,000,000 bytes 大約是 28.6MB,單次 Request 上傳的大小限制可以在 Web.config 修改 maxAllowedContentLength
。如下:<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<security>
<requestFiltering>
<!-- 100MB -->
<requestLimits maxAllowedContentLength="104857600" />
</requestFiltering>
</security>
</system.webServer>
</configuration>
File uploads in ASP.NET Core
Uploading Files In ASP.net Core